# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.

# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.

# Copyright (C) 2022-present Naver Corporation. All rights reserved.
# Licensed under CC BY-NC-SA 4.0 (non-commercial use only).
#
# --------------------------------------------------------
# utilitary functions for CroCo
# --------------------------------------------------------
# References:
# MAE: https://github.com/facebookresearch/mae
# DeiT: https://github.com/facebookresearch/deit
# BEiT: https://github.com/microsoft/unilm/tree/master/beit
# --------------------------------------------------------

import builtins
import datetime
import json
import math
import os
import time
from collections import defaultdict, deque
from pathlib import Path

import numpy as np
import torch
import torch.distributed as dist
from torch import inf


class SmoothedValue(object):
    """Track a series of values and provide access to smoothed values over a
    window or the global series average.
    """

    def __init__(self, window_size=20, fmt=None):
        if fmt is None:
            fmt = "{median:.4f} ({global_avg:.4f})"
        self.deque = deque(maxlen=window_size)
        self.total = 0.0
        self.count = 0
        self.fmt = fmt

    def update(self, value, n=1):
        self.deque.append(value)
        self.count += n
        self.total += value * n

    def synchronize_between_processes(self):
        """
        Warning: does not synchronize the deque!
        """
        if not is_dist_avail_and_initialized():
            return
        t = torch.tensor([self.count, self.total], dtype=torch.float64, device="cuda")
        dist.barrier()
        dist.all_reduce(t)
        t = t.tolist()
        self.count = int(t[0])
        self.total = t[1]

    @property
    def median(self):
        d = torch.tensor(list(self.deque))
        return d.median().item()

    @property
    def avg(self):
        d = torch.tensor(list(self.deque), dtype=torch.float32)
        return d.mean().item()

    @property
    def global_avg(self):
        return self.total / self.count

    @property
    def max(self):
        return max(self.deque)

    @property
    def value(self):
        return self.deque[-1]

    def __str__(self):
        return self.fmt.format(
            median=self.median,
            avg=self.avg,
            global_avg=self.global_avg,
            max=self.max,
            value=self.value,
        )


class MetricLogger(object):
    def __init__(self, delimiter="\t"):
        self.meters = defaultdict(SmoothedValue)
        self.delimiter = delimiter

    def update(self, **kwargs):
        for k, v in kwargs.items():
            if v is None:
                continue
            if isinstance(v, torch.Tensor):
                v = v.item()
            assert isinstance(v, (float, int))
            self.meters[k].update(v)

    def __getattr__(self, attr):
        if attr in self.meters:
            return self.meters[attr]
        if attr in self.__dict__:
            return self.__dict__[attr]
        raise AttributeError(
            "'{}' object has no attribute '{}'".format(type(self).__name__, attr)
        )

    def __str__(self):
        loss_str = []
        for name, meter in self.meters.items():
            loss_str.append("{}: {}".format(name, str(meter)))
        return self.delimiter.join(loss_str)

    def synchronize_between_processes(self):
        for meter in self.meters.values():
            meter.synchronize_between_processes()

    def add_meter(self, name, meter):
        self.meters[name] = meter

    def log_every(self, iterable, print_freq, header=None, max_iter=None):
        i = 0
        if not header:
            header = ""
        start_time = time.time()
        end = time.time()
        iter_time = SmoothedValue(fmt="{avg:.4f}")
        data_time = SmoothedValue(fmt="{avg:.4f}")
        len_iterable = min(len(iterable), max_iter) if max_iter else len(iterable)
        space_fmt = ":" + str(len(str(len_iterable))) + "d"
        log_msg = [
            header,
            "[{0" + space_fmt + "}/{1}]",
            "eta: {eta}",
            "{meters}",
            "time: {time}",
            "data: {data}",
        ]
        if torch.cuda.is_available():
            log_msg.append("max mem: {memory:.0f}")
        log_msg = self.delimiter.join(log_msg)
        MB = 1024.0 * 1024.0
        for it, obj in enumerate(iterable):
            data_time.update(time.time() - end)
            yield obj
            iter_time.update(time.time() - end)
            if i % print_freq == 0 or i == len_iterable - 1:
                eta_seconds = iter_time.global_avg * (len_iterable - i)
                eta_string = str(datetime.timedelta(seconds=int(eta_seconds)))
                if torch.cuda.is_available():
                    print(
                        log_msg.format(
                            i,
                            len_iterable,
                            eta=eta_string,
                            meters=str(self),
                            time=str(iter_time),
                            data=str(data_time),
                            memory=torch.cuda.max_memory_allocated() / MB,
                        )
                    )
                else:
                    print(
                        log_msg.format(
                            i,
                            len_iterable,
                            eta=eta_string,
                            meters=str(self),
                            time=str(iter_time),
                            data=str(data_time),
                        )
                    )
            i += 1
            end = time.time()
            if max_iter and it >= max_iter:
                break
        total_time = time.time() - start_time
        total_time_str = str(datetime.timedelta(seconds=int(total_time)))
        print(
            "{} Total time: {} ({:.4f} s / it)".format(
                header, total_time_str, total_time / len_iterable
            )
        )


def setup_for_distributed(is_master):
    """
    This function disables printing when not in master process
    """
    builtin_print = builtins.print

    def print(*args, **kwargs):
        force = kwargs.pop("force", False)
        force = force or (get_world_size() > 8)
        if is_master or force:
            now = datetime.datetime.now().time()
            builtin_print("[{}] ".format(now), end="")  # print with time stamp
            builtin_print(*args, **kwargs)

    builtins.print = print


def is_dist_avail_and_initialized():
    if not dist.is_available():
        return False
    if not dist.is_initialized():
        return False
    return True


def get_world_size():
    if not is_dist_avail_and_initialized():
        return 1
    return dist.get_world_size()


def get_rank():
    if not is_dist_avail_and_initialized():
        return 0
    return dist.get_rank()


def is_main_process():
    return get_rank() == 0


def save_on_master(*args, **kwargs):
    if is_main_process():
        torch.save(*args, **kwargs)


def init_distributed_mode(args):
    nodist = args.nodist if hasattr(args, "nodist") else False
    if "RANK" in os.environ and "WORLD_SIZE" in os.environ and not nodist:
        args.rank = int(os.environ["RANK"])
        args.world_size = int(os.environ["WORLD_SIZE"])
        args.gpu = int(os.environ["LOCAL_RANK"])
    else:
        print("Not using distributed mode")
        setup_for_distributed(is_master=True)  # hack
        args.distributed = False
        return

    args.distributed = True

    torch.cuda.set_device(args.gpu)
    args.dist_backend = "nccl"
    print(
        "| distributed init (rank {}): {}, gpu {}".format(
            args.rank, args.dist_url, args.gpu
        ),
        flush=True,
    )
    torch.distributed.init_process_group(
        backend=args.dist_backend,
        init_method=args.dist_url,
        world_size=args.world_size,
        rank=args.rank,
    )
    torch.distributed.barrier()
    setup_for_distributed(args.rank == 0)


class NativeScalerWithGradNormCount:
    state_dict_key = "amp_scaler"

    def __init__(self, enabled=True):
        self._scaler = torch.cuda.amp.GradScaler(enabled=enabled)

    def __call__(
        self,
        loss,
        optimizer,
        clip_grad=None,
        parameters=None,
        create_graph=False,
        update_grad=True,
    ):
        self._scaler.scale(loss).backward(create_graph=create_graph)
        if update_grad:
            if clip_grad is not None:
                assert parameters is not None
                self._scaler.unscale_(
                    optimizer
                )  # unscale the gradients of optimizer's assigned params in-place
                norm = torch.nn.utils.clip_grad_norm_(parameters, clip_grad)
            else:
                self._scaler.unscale_(optimizer)
                # TODO: FIXME: get_grad_norm_ is a poorly-implemented func that is very slow, and its return not even used
                # norm = get_grad_norm_(parameters)
                norm = None
            self._scaler.step(optimizer)
            self._scaler.update()
        else:
            norm = None
        return norm

    def state_dict(self):
        return self._scaler.state_dict()

    def load_state_dict(self, state_dict):
        self._scaler.load_state_dict(state_dict)


def get_grad_norm_(parameters, norm_type: float = 2.0) -> torch.Tensor:
    if isinstance(parameters, torch.Tensor):
        parameters = [parameters]
    parameters = [p for p in parameters if p.grad is not None]
    norm_type = float(norm_type)
    if len(parameters) == 0:
        return torch.tensor(0.0)
    device = parameters[0].grad.device
    if norm_type == inf:
        total_norm = max(p.grad.detach().abs().max().to(device) for p in parameters)
    else:
        total_norm = torch.norm(
            torch.stack(
                [torch.norm(p.grad.detach(), norm_type).to(device) for p in parameters]
            ),
            norm_type,
        )
    return total_norm


def save_model(
    args, epoch, model_without_ddp, optimizer, loss_scaler, fname=None, best_so_far=None
):
    output_dir = Path(args.output_dir)
    if fname is None:
        fname = str(epoch)
    checkpoint_path = output_dir / ("checkpoint-%s.pth" % fname)
    to_save = {
        "model": model_without_ddp.state_dict(),
        "optimizer": optimizer.state_dict(),
        "scaler": loss_scaler.state_dict(),
        "args": args,
        "epoch": epoch,
    }
    if best_so_far is not None:
        to_save["best_so_far"] = best_so_far
    print(f">> Saving model to {checkpoint_path} ...")
    save_on_master(to_save, checkpoint_path)


def load_model(args, model_without_ddp, optimizer, loss_scaler):
    args.start_epoch = 0
    best_so_far = None
    if args.resume is not None:
        if args.resume.startswith("https"):
            checkpoint = torch.hub.load_state_dict_from_url(
                args.resume, map_location="cpu", check_hash=True
            )
        else:
            checkpoint = torch.load(args.resume, map_location="cpu")
        print("Resume checkpoint %s" % args.resume)
        model_without_ddp.load_state_dict(checkpoint["model"], strict=False)
        args.start_epoch = checkpoint["epoch"] + 1
        optimizer.load_state_dict(checkpoint["optimizer"])
        if "scaler" in checkpoint:
            loss_scaler.load_state_dict(checkpoint["scaler"])
        if "best_so_far" in checkpoint:
            best_so_far = checkpoint["best_so_far"]
            print(" & best_so_far={:g}".format(best_so_far))
        else:
            print("")
        print("With optim & sched! start_epoch={:d}".format(args.start_epoch), end="")
    return best_so_far


def all_reduce_mean(x):
    world_size = get_world_size()
    if world_size > 1:
        x_reduce = torch.tensor(x).cuda()
        dist.all_reduce(x_reduce)
        x_reduce /= world_size
        return x_reduce.item()
    else:
        return x


def _replace(text, src, tgt, rm=""):
    """Advanced string replacement.
    Given a text:
    - replace all elements in src by the corresponding element in tgt
    - remove all elements in rm
    """
    if len(tgt) == 1:
        tgt = tgt * len(src)
    assert len(src) == len(tgt), f"'{src}' and '{tgt}' should have the same len"
    for s, t in zip(src, tgt):
        text = text.replace(s, t)
    for c in rm:
        text = text.replace(c, "")
    return text


def filename(obj):
    """transform a python obj or cmd into a proper filename.
    - \1 gets replaced by slash '/'
    - \2 gets replaced by comma ','
    """
    if not isinstance(obj, str):
        obj = repr(obj)
    obj = str(obj).replace("()", "")
    obj = _replace(obj, "_,(*/\1\2", "-__x%/,", rm=" )'\"")
    assert all(len(s) < 256 for s in obj.split(os.sep)), (
        "filename too long (>256 characters):\n" + obj
    )
    return obj


def _get_num_layer_for_vit(var_name, enc_depth, dec_depth):
    if any(k in var_name for k in ("cls_token", "mask_token", "pos_embed", "global_tokens", "pose_token")):
        return 0
    elif "patch_embed" in var_name:
        return 0
    elif "enc_blocks"in var_name:
        layer_id = int(var_name.split(".")[2])
        return layer_id + 1
    elif any(k in var_name for k in ["decoder_embed", "enc_norm"]):  # part of the last black
        return enc_depth
    elif "dec_blocks"in var_name:
        layer_id = int(var_name.split(".")[2])
        return enc_depth + layer_id + 1
    elif "dec_norm"in var_name:  # part of the last block
        return enc_depth + dec_depth
    elif any(k in var_name for k in ["head", "prediction_head"]):
        return enc_depth + dec_depth + 1
    else:
        raise NotImplementedError(var_name)

def get_parameter_groups2(
    # ### CHANGED ###
    # We now accept a dictionary of named parameters instead of the whole model.
    named_params_map: dict[str, torch.Tensor],
    weight_decay: float,
    # ### NEW ###
    # We pass in layer decay details explicitly instead of accessing the model.
    layer_decay: float = 1.0,
    enc_depth: int | None = None,
    dec_depth: int | None = None,
    # --- The rest of the arguments are the same ---
    scale_keywords: list = [],
    lr_scales: list = [],
    skip_list: tuple = (),
    no_lr_scale_list: list = []
):
    """
    Creates parameter groups with support for weight decay, layer-wise learning
    rate decay, and custom learning rate scaling for specific keywords.
    """
    parameter_group_names = {}
    parameter_group_vars = {}

    # ### CHANGED ###
    # This block no longer accesses `model.encoder_args` or `model.decoder_args`.
    # It now relies on the arguments passed directly into the function.
    if layer_decay < 1.0:
        # ### WHY ###
        # This makes the function self-contained. It doesn't need to know
        # about the model's internal structure, only the number of layers.
        if enc_depth is None or dec_depth is None:
            raise ValueError("enc_depth and dec_depth must be provided for layer_decay < 1.0")
        num_layers = enc_depth + dec_depth
        layer_decay_values = list(
            layer_decay ** (num_layers + 1 - i) for i in range(num_layers + 2)
        )

    # ### CHANGED ###
    # The main loop now iterates over the dictionary we passed in.
    # for name, param in model.named_parameters():
    for name, param in named_params_map.items():
        # ### WHY ###
        # This allows the function to operate on any subset of parameters,
        # which is exactly what we need for our Muon/AdamW split.

        if not param.requires_grad:
            continue  # frozen weights

        #
        # --- NO CHANGES NEEDED BELOW THIS LINE ---
        # The entire rest of the function's logic is based on `name` and `param`,
        # which we still have. It will work perfectly as is.
        #

        # Assign weight decay values
        if len(param.shape) == 1 or name.endswith(".bias") or name in skip_list:
            group_name = "no_decay"
            this_weight_decay = 0.0
        else:
            group_name = "decay"
            this_weight_decay = weight_decay

        kw_id = -1
        for i, kw in enumerate(scale_keywords):
            if kw in name:
                kw_id = i
                break

        if kw_id >= 0:
            group_name = '_'.join(name.split(".")[:2] + [group_name])
            scale = lr_scales[kw_id]
        # Assign layer ID for LR scaling
        elif layer_decay < 1.0:
            # This helper function `_get_num_layer_for_vit` is assumed to exist
            # and take `name`, `enc_depth`, and `dec_depth` as input.
            layer_id = _get_num_layer_for_vit(name, enc_depth, dec_depth)
            group_name = "layer_%d_%s" % (layer_id, group_name)
            scale = layer_decay_values[layer_id]
            if name in no_lr_scale_list:
                group_name = f"{group_name}_no_lr_scale"
                scale = 1.0
        else:
            layer_id = 0
            scale = 1.0

        if group_name not in parameter_group_names:
            parameter_group_names[group_name] = {
                "weight_decay": this_weight_decay,
                "params": [],
                "lr_scale": scale,
            }
            parameter_group_vars[group_name] = {
                "weight_decay": this_weight_decay,
                "params": [],
                "lr_scale": scale,
            }

        parameter_group_vars[group_name]["params"].append(param)
        parameter_group_names[group_name]["params"].append(name)
        
    return list(parameter_group_vars.values())

def refine_and_split_groups_for_muon(
    param_groups,
    param_names, # We need the model to get parameter names
    head_lr_scale=0.5, # A multiplier for the head's learning rate
    muon_lr_scale=10, 
    betas=(0.9, 0.95)
):
    """
    Post-processes groups from get_parameter_groups, splitting them into
    Muon, Adam-Embed, Adam-Head, and Adam-Scalar sub-groups.

    This combines the layer-wise decay logic of the first function with the
    parameter-type logic of the second.
    """
    final_groups = []
    for group, names in zip(param_groups, param_names):
        # For each group from get_parameter_groups, create four buckets
        buckets = {
            "muon": [],
            "adam_embed": [],
            "adam_head": [],
            "adam_scalar": [],
        }

        # Use your logic to sort params from this group into the buckets
        for param, name in zip(group['params'], names['params']):
            if 'embedding' in name:
                buckets['adam_embed'].append(param)
            elif 'output_head' in name:
                buckets['adam_head'].append(param)
            elif param.ndim >= 2 and 'weight' in name:
                buckets['muon'].append(param)
            else:
                buckets['adam_scalar'].append(param)

        # Now, create new, final groups from the non-empty buckets,
        # inheriting the original group's settings.
        
        # Muon sub-group
        if buckets['muon']:
            final_groups.append({
                'params': buckets['muon'],
                'use_muon': True, 'momentum': 0.95,
                'weight_decay': group['weight_decay'], 'lr_scale': group.get('lr_scale', 1.0)*muon_lr_scale
            })

        # Adam Embedding sub-group
        if buckets['adam_embed']:
            final_groups.append({
                'params': buckets['adam_embed'],
                'use_muon': False, 'betas': betas,
                'weight_decay': group['weight_decay'], 'lr_scale': group.get('lr_scale', 1.0)
            })

        # Adam Head sub-group (with an additional LR scale)
        if buckets['adam_head']:
            final_groups.append({
                'params': buckets['adam_head'],
                'use_muon': False, 'betas': betas,
                'weight_decay': group['weight_decay'], 
                'lr_scale': group.get('lr_scale', 1.0) * head_lr_scale
            })

        # Adam Scalar sub-group
        if buckets['adam_scalar']:
            final_groups.append({
                'params': buckets['adam_scalar'],
                'use_muon': False, 'betas': betas,
                'weight_decay': group['weight_decay'], 'lr_scale': group.get('lr_scale', 1.0)
            })

    return final_groups

def create_muon_param_groups(model, muon_lr=0.05, adam_lr=1e-3, betas=(0.9, 0.95)):
    """
    Separates model parameters into groups for the MuonWithAuxAdam optimizer.
    
    Args:
        model (nn.Module): The model whose parameters need to be grouped.
        muon_lr (float): Learning rate for the Muon group.
        adam_lr (float): Base learning rate for the Adam groups.
        
    Returns:
        list: A list of parameter group dictionaries.
    """
    muon_params = []
    adam_embedding_params = []
    adam_head_params = []
    adam_scalar_params = []

    print("--- Creating Muon Parameter Groups ---")
    for name, p in model.named_parameters():
        if not p.requires_grad:
            continue
        
        if 'embedding' in name:
            print(f"  [Adam Embed Group] {name}")
            adam_embedding_params.append(p)
        elif 'output_head' in name:
            print(f"  [Adam Head Group] {name}")
            adam_head_params.append(p)
        elif p.ndim >= 2 and 'weight' in name:
            print(f"  [Muon Group] {name}")
            muon_params.append(p)
        else:
            print(f"  [Adam Scalar Group] {name}")
            adam_scalar_params.append(p)

    param_groups = [
        {'params': muon_params, 'use_muon': True, 'lr': muon_lr, 'momentum': 0.95, 'weight_decay': 0.0,},
        {'params': adam_embedding_params, 'use_muon': False, 'lr': adam_lr, 'betas': betas},
        {'params': adam_head_params, 'use_muon': False, 'lr': adam_lr / 2, 'betas': betas}, # Example: different LR for head
        {'params': adam_scalar_params, 'use_muon': False, 'lr': adam_lr,'betas': betas},
    ]
    print("------------------------------------")
    
    return param_groups

def get_parameter_groups(
    model, weight_decay, scale_keywords=[], lr_scales=[],
    layer_decay=1.0, skip_list=(), no_lr_scale_list=[], return_name=False
):
    parameter_group_names = {}
    parameter_group_vars = {}
    enc_depth, dec_depth = None, None
    # prepare layer decay values
    assert layer_decay == 1.0 or 0.0 < layer_decay < 1.0
    if layer_decay < 1.0:
        enc_depth = model.encoder_args["embed_dim"]
        dec_depth = model.decoder_args["embed_dim"]
        num_layers = enc_depth + dec_depth
        layer_decay_values = list(
            layer_decay ** (num_layers + 1 - i) for i in range(num_layers + 2)
        )

    for name, param in model.named_parameters():
        if not param.requires_grad:
            continue  # frozen weights

        # Assign weight decay values
        if len(param.shape) == 1 or name.endswith(".bias") or name in skip_list:
            group_name = "no_decay"
            this_weight_decay = 0.0
        else:
            group_name = "decay"
            this_weight_decay = weight_decay

        kw_id = -1
        for i, kw in enumerate(scale_keywords):
            if kw in name:
                kw_id = i
                break

        if kw_id >= 0:
            group_name = '_'.join(name.split(".")[:2] + [group_name])
            scale = lr_scales[kw_id]
        # Assign layer ID for LR scaling
        elif layer_decay < 1.0:
            layer_id = _get_num_layer_for_vit(name, enc_depth, dec_depth)
            group_name = "layer_%d_%s" % (layer_id, group_name)
            scale = layer_decay_values[layer_id]
            if name in no_lr_scale_list:
                group_name = f"{group_name}_no_lr_scale"
                scale = 1.0
        else:
            layer_id = 0
            scale = 1.0

        if group_name not in parameter_group_names:
            parameter_group_names[group_name] = {
                "weight_decay": this_weight_decay,
                "params": [],
                "lr_scale": scale,
            }
            parameter_group_vars[group_name] = {
                "weight_decay": this_weight_decay,
                "params": [],
                "lr_scale": scale,
            }

        parameter_group_vars[group_name]["params"].append(param)
        parameter_group_names[group_name]["params"].append(name)
    # print("Param groups = %s" % json.dumps(parameter_group_names, indent=2))
    if return_name:
        return list(parameter_group_vars.values()), list(parameter_group_names.values())
    return list(parameter_group_vars.values())

def adjust_learning_rate_by_step(
    optimizer, current_step: int, base_lr: float, min_lr: float, warmup_steps: int, total_steps: int
):
    """
    Decay the learning rate with half-cycle cosine after a linear warmup, based on steps.

    Args:
        optimizer: The optimizer to update.
        current_step: The current training step number.
        base_lr: The initial learning rate after warmup.
        min_lr: The minimum learning rate to decay to.
        warmup_steps: The number of steps for the linear warmup.
        total_steps: The total number of training steps.
    """
    # Linear warmup
    if current_step < warmup_steps:
        # Prevent division by zero if warmup_steps is 0
        if warmup_steps > 0:
            lr = base_lr * current_step / warmup_steps
        else:
            lr = base_lr
    # Cosine decay
    else:
        # Progress in the cosine decay phase (from 0 to 1)
        progress = (current_step - warmup_steps) / (total_steps - warmup_steps)
        lr = min_lr + (base_lr - min_lr) * 0.5 * (
            1.0 + math.cos(math.pi * progress)
        )

    # Apply the learning rate to all parameter groups
    for param_group in optimizer.param_groups:
        if "lr_scale" in param_group:
            param_group["lr"] = lr * param_group["lr_scale"]
        else:
            param_group["lr"] = lr
            
    return lr

# Refactored to be more generic
def adjust_learning_rate2(optimizer, epoch, base_lr, min_lr, warmup_epochs, total_epochs):
    """Decay the learning rate with half-cycle cosine after warmup"""
    if epoch < warmup_epochs:
        lr = base_lr * epoch / warmup_epochs
    else:
        lr = min_lr + (base_lr - min_lr) * 0.5 * (
            1.0
            + math.cos(
                math.pi
                * (epoch - warmup_epochs)
                / (total_epochs - warmup_epochs)
            )
        )

    for param_group in optimizer.param_groups:
        if "lr_scale" in param_group:
            param_group["lr"] = lr * param_group["lr_scale"]
        else:
            param_group["lr"] = lr
    return lr

def adjust_learning_rate(optimizer, epoch, args):
    """Decay the learning rate with half-cycle cosine after warmup"""

    if epoch < args.warmup_epochs:
        lr = args.lr * epoch / args.warmup_epochs
    else:
        lr = args.min_lr + (args.lr - args.min_lr) * 0.5 * (
            1.0
            + math.cos(
                math.pi
                * (epoch - args.warmup_epochs)
                / (args.epochs - args.warmup_epochs)
            )
        )

    for param_group in optimizer.param_groups:
        if "lr_scale" in param_group:
            param_group["lr"] = lr * param_group["lr_scale"]
        else:
            param_group["lr"] = lr

    return lr
